Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Don't re-authorize objects that were part of a scoped list #3994

Merged
merged 12 commits into from
Jul 25, 2023

Conversation

rmosolgo
Copy link
Owner

@rmosolgo rmosolgo commented Mar 19, 2022

After a list has been scoped, don't re-run authorization on the objects it permitted.

Fixes #3893

TODO:

  • consider the breaking-ness of this change. Make it opt-in instead of default?
  • somehow pass this thru connection -> edges -> node and connection -> nodes
  • use .equal? to test same-object, add a test for that behavior

@rmosolgo
Copy link
Owner Author

cc @hvenables I've worked up a basic implementation, described here: https://github.com/rmosolgo/graphql-ruby/pull/3994/files#diff-d60a70644c1e9b36288190a468466b7186cd98ab45bb3519e30b922fc9708cac

Does that sound like it would work for what you had in mind?

@fameoflight
Copy link

Would love to have this available in next version. We have usecase for this as well. Let me know if I can help in anyway.

@rmosolgo
Copy link
Owner Author

Sure thing, I'll try to wrap it up soon. The trick is determining when an application-defined scope_items method was applied. I've been trying to use the return value, but I don't think that's right: sometimes, applying scope_items will return the exact same list that was passed in.

Instead, I think I'll try inspecting .method(:scope_items) to see if it's the default implementation or a custom implementation. The other option is, in the default implementation, checking caller to see if an application-defined method is calling through to it. (Maybe both of those approaches will be required.)

@bessey
Copy link
Contributor

bessey commented Aug 5, 2022

@hvenables left the company (enjoy Shopify Harry 😛) so I'll have to fill in on feedback! This definitely works for us. Our GraphQL Pundit authorization architecture is essentially

  • All objects are expected to have a policy by default
  • All objects are authorized with #view? by default (we felt show? was inappropriate given the non RESTy nature of GraphQL, though in practice view? and show? are aliases for cross compatibility`
  • #view? is expected to behave the same as the policy's scope, i.e. it should never return false when for scoped items

So given your API we would probably implement reauthorize_scoped_objects(false) on our BaseObject.

We're still very keen for this API btw! Authorization induced N+1s is one of our biggest perf issues in our GraphQL API.

@rmosolgo
Copy link
Owner Author

rmosolgo commented Aug 5, 2022

Thanks for sharing your thoughts about it! I'll make some time to land this PR soon.

@bessey
Copy link
Contributor

bessey commented Aug 31, 2022

I've been thinking about this more lately, in the context of #4087, because we perform the same scoping that GraphQL Pro Pundit does to ActiveRecord::Relations, within all our ActiveRecord::Relation sources.

In this context, I cannot think of a way that you could indicate to the runtime "hey, I know you're returning an instance of MyRecord, but trust me, I loaded that through a dataloader which scoped it, so you do not need to authorize it again". The only option I can think that could work is

  1. Set some state on the ActiveRecord::Relation object
  2. Modify ActiveRecord's behaviour to persist this state through to all records instantiated from that Relation

e.g. theoretical API

scope = MyRecord.where(visible: true).mark_authorized!(:view) # sets @authorized = :view
results = scope.to_a # ActiveRecord sets @authorized on each instantiated record 
results.first.pre_authorized?(:view) => true # accesses record specific @authorized

I have no idea if this is actually possible without monkeypatching ActiveRecord yet, but I thought I'd put my thoughts somewhere others with more experience might see!

EDIT: Yeah, I looked into the AR source to see how 7.0's strict record loading works and it certainly suggests there's no API flexible enough to do this from the outside.

However, I just realised that you don't need to do pass the state through the relation. The Dataloader is responsible for instantiating the relation, so it is well placed to add this state to the records! We could easily modify a source's #fetch(keys) method to set this state on every loaded record, which is of course visible to the GraphQL authorization framework and therefore our own authorization framework.

@rmosolgo rmosolgo added this to the 2.1 milestone Apr 7, 2023
@ioquatix
Copy link

We appear to be running into the same issue, any chance this will make it into a release soon? Thanks!

@rmosolgo rmosolgo changed the base branch from master to 2.1-dev July 25, 2023 14:57
@rmosolgo
Copy link
Owner Author

I'll address the lint failure in another branch...

@rmosolgo rmosolgo merged commit 3cc2a09 into 2.1-dev Jul 25, 2023
12 of 13 checks passed
@rmosolgo rmosolgo deleted the scope-no-redundant-auth branch July 25, 2023 17:19
@ioquatix
Copy link

Thanks for merging this. Any chance we can get an ETA on when it would potentially be released?

@rmosolgo
Copy link
Owner Author

I just finally released this in GraphQL-Ruby 2.1.0 😅

@ioquatix
Copy link

Thanks, we will try it out and report back!

@bmulholland
Copy link

bmulholland commented Sep 5, 2023

Hi! Thanks for the contributions. Two questions:

  1. The release notes for 2.1 list this as a breaking change, but the docs and code suggest that this isn't enabled by default, which means it's not a breaking change. Which is it?
  2. I am worried about a very simple footgun: move an existing method to the dataloader pattern and bam: data exposed to other users. I've previously described this in more detail. In brief: scope_items can sometimes be called with an array -- e.g. when they come from a dataloader -- in which case those aren't actually scoped. We rely on the authorized? to be a backstop for what is therefore a potentially complex integration error that almost results in a data leak. Doesn't this PR remove that backstop, leaving us exposed to the scenario I describe?

I could totally be misunderstanding this -- after years of working with these parts, I still haven't wrapped my head around it. Please fill me in if I'm missing anything important, I'd love to be wrong on this.

@@ -10,7 +10,13 @@ def after_resolve(object:, arguments:, context:, value:, memo:)
else
ret_type = @field.type.unwrap
if ret_type.respond_to?(:scope_items)
ret_type.scope_items(value, context)
scoped_items = ret_type.scope_items(value, context)
if !scoped_items.equal?(value) && !ret_type.reauthorize_scoped_objects

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per my comment, perhaps this should also check the type of value, so that it doesn't skip authorizing arrays twice?

@rmosolgo
Copy link
Owner Author

rmosolgo commented Sep 7, 2023

Hey @bmulholland, thanks for pointing out that issue with the Changelog. I updated it in a3c093f.

move an existing method to the dataloader pattern and bam

Could you give a before-and-after example of a refactor where this would happen? I would expect lists to go through scope_items regardless of how the data was loaded, but maybe I've missed a spot!

@bmulholland
Copy link

bmulholland commented Sep 8, 2023

I updated it in a3c093f.

Thanks for clarifying, appreciate it!

Could you give a before-and-after example of a refactor where this would happen?

Thanks for getting me to check this again. It looks like this is only in effect when using the Pundit integration -- so the footgun is limited to that scenario.

It looks like this is actually a risk for us, but only from our own app's code. That's because of GraphQL-Ruby-related history: We were trying to use the Pundit integration but honestly we could never figure out how the concepts fit together so we dropped the attempt. However, because of that, we still have explicit handling of arrays in our scope_items calls, which leads to this issue.

We'll discuss the best way to handle this internally: either somehow ensure that the "disable authorized?" option is never enabled, or have a policy that Arrays in scope_items are always scoped too. No action needed from GraphQL, though this does feel a bit too close for comfort.

@jderose9
Copy link

jderose9 commented Nov 29, 2023

The documentation for this change would lead me to believe that the default is to reauthorize scoped objects, however after observing the behavior and examining the code it would seem that the default behavior has changed (a breaking change) and that scoped objects are no longer re-authorized. This means that scopes which were written too broadly with the assumption that objects would be further authorized by the policy method will now unintentionally be exposing objects after upgrading.

The check seems to be here, and I don't see anything defaulting this value to true:

if !scoped_items.equal?(value) && !ret_type.reauthorize_scoped_objects

Additionally, unless I've got something setup incorrectly, I have found that calling reauthorize_scoped_objects(true) in the node type's class (as documented here) doesn't seem to do anything. I had to call it in the connection type's class.

@bmulholland
Copy link

bmulholland commented Nov 30, 2023

Uh, what? That's really alarming: as noted above, this is a big risk for us. It's incredibly critical that we don't expose data to the wrong users, and we need all the checks that we can get. I think that's worth it's own Issue, right?

More broadly, as I've noted a few times, there's several moving parts here that lead to a high risk for disastrous integration-level bugs: Dataloaders, array handling, and scope re-authorization could easily combine together to skip all checks, exposing all data to users. Since there are several options and configuration variations of these, this is probably hard to both test thoroughly and reason about. Even if the specifics here mean there's no/low concern, I've already had a few similar conversations on this project that have ended with that same outcome. What happens when several areas of "no concern" collide?

@jderose9
Copy link

jderose9 commented Nov 30, 2023

More broadly, as I've noted a few times, there's several moving parts here that lead to a high risk for disastrous integration-level bugs: Dataloaders, array handling, and scope re-authorization could easily combine together to skip all checks, exposing all data to users. Since there are several options and configuration variations of these, this is probably hard to both test thoroughly and reason about. Even if the specifics here mean there's no/low concern, I've already had a few similar conversations on this project that have ended with that same outcome. What happens when several areas of "no concern" collide?

I agree. Also, in my situation, while I would like to get as close as possible to loading only the permitted objects for performance reasons (ie. I'm not going to load objects for a completely different tenant), sometimes there is code that runs in the policy method that is difficult or impossible to reproduce in a database scope. So certainly I don't think the default behavior should have changed between versions.

@rmosolgo
Copy link
Owner Author

rmosolgo commented Dec 1, 2023

Hey @jderose9, thanks for reporting these problems -- I've opened #4720 address them!

@jderose9
Copy link

jderose9 commented Dec 1, 2023

@rmosolgo Awesome, thank you very much for the fast resolution!

@bessey
Copy link
Contributor

bessey commented Dec 4, 2023

More broadly, as I've noted a few times, there's several moving parts here that lead to a high risk for disastrous integration-level bugs: Dataloaders, array handling, and scope re-authorization could easily combine together to skip all checks, exposing all data to users

I share this concern of yours @bmulholland. We are GraphQL Pro (and Ent) users and make heavy use of Dataloaders and the GraphQL Pro Pundit integration. I tried to adopt this new optimisation in a spike, but ran into a problem like yours: it has no effect for Dataloaders. I believe because they (or at least ours) load an Array, and Pundit can't do anything useful with scoping an already instantiated Array.

That's not a security issue at least, but it does mean these two GraphQL Ruby provided tools are not compatible with one another.

I've been doing a lot of GraphQL Ruby perf optimisation work lately on our codebase, and I keep coming back to one thing: reasoning about this stuff is getting incredibly difficult. I have used GraphQL Ruby for years and still I have a hard time reasoning about how these factors combine:

(I know the answers to these Qs now, or so I hope, but just to illustrate how difficult this is to reason about):

  • Dataloaders
    • Should a Source do its own authorization?
  • Connection objects (both automatically wrapped and manually returned from resolvers)
    • Is authorization done before or after pagination?
    • What object is passed to scope_items in each case?
    • Pundit Scopes expect an ActiveRecord::Relation, what should I do with a connection object?
  • The Pro Pundit integration (and more broadly the authorization framework itself)
    • Object vs field level authz
    • Scope items, and its special case for Arrays and Arrays only
    • This new scope optimisation

We've used Module#prepend to wrap the GraphQL Pro Pundit integration with verbose debug logging of the decisions it makes, and still I regularly bundle open GraphQL + GraphQL Pro just to understand the control flow through these parts.

I'm coming around to the view point that

  1. The costs of outsourcing such a rich integration as GraphQL + Pundit to a 3rd party library outweigh the benefits
  2. This new scope optimisation does not belong in GraphQL Ruby at all. It is a great idea, but is better implemented by the application developer and richly integrated with their specific authorization strategy, rather than an additional layer of complexity to reason about

Is it constructive to continue this conversation, and if so is GH Discussions the best place to do so? I appreciate my problems can largely be solved by ignoring this features existence and building our own Pundit integration, but since it seems others in the community have similar concerns, perhaps its worth discussing nonetheless?

PS I don't want to come across as ungrateful, GraphQL Ruby is a wonderful piece of software ❤️

@rmosolgo
Copy link
Owner Author

rmosolgo commented Dec 7, 2023

Hey @bessey and @bmulholland, thanks for your detailed writeups on the conflict between these features -- and sorry for the trouble you've run into so far on them :S I can see that they need some reconsideration. Over the next couple of days I'm going to re-read your descriptions and suggestions and open a new issue with some proposals. I'll follow up back here when I do 👍

@rmosolgo
Copy link
Owner Author

rmosolgo commented Dec 8, 2023

I went digging on the Pundit Arrays issue and found a previous suggestion which seems like a good candidate for a new default behavior. I worked out the code locally and wrote up a description here: #4726

@bmulholland and @bessey , could you let me know on that issue what you think, if you have a minute? Thanks!

@iulia-b
Copy link

iulia-b commented Jun 25, 2024

👋 Hey @rmosolgo. We are updating our GraphQL gem to 2.3 and want to migrate to using the reauthorized_scoped_items for a field A, which is a connection to a parent field P. The field A is computed through a resolver, where all the logic resides. Additionally, there is no need for authorizing this list of objects of type A once the parent (of type P) has been previously authorized.

In order to do this we want to use reauthorize_scoped_items(false) on type A. To match our use case, we have to return in scope_items a clone of the resolved items in order to bypass this check.

def self.scope_items(items, context)
  items.clone
end

reauthorize_scoped_objects(false)

It’s not clear to me if we are missusing this feature. Why do scope_items have to be different than items in order to skip authorization?

Did I understand correctly the behavior? Is it possible to introduce any bugs or potential security issues by returning a clone of items?

@rmosolgo
Copy link
Owner Author

Hey @iulia-b, great question. Yes, that's the best way to handle your situation.

For lack of better implementation, GraphQL-Ruby checks was_scoped = !prev_list.equal?(new_list), that is, it makes sure that Type.scope_items(prev_list, ctx) returns some different Ruby object. That's how GraphQL-Ruby knows that scoping was applied.

In your case, you're using the resolver to scope the list, so scope_items is a no-op. But, you have to return a new object so GraphQL-Ruby does the right thing. I think .clone is a good solution here.

The security issue would be if any other fields return lists of this same type. Do those other lists need scoping? in that case, you'd need a way of detecting whether items came from this particular resolver or from somewhere else (for example, you could apply a wrapper object and remove the wrapper in .scope_items).

I hope this helps!

@alexus37
Copy link

alexus37 commented Aug 2, 2024

Thank you for the explanations, @rmosolgo! I'm curious if there might be a more efficient solution instead of cloning. Could we enhance the return type of the scope_items function to not only return the items but also a flag indicating if scoping was applied? Alternatively, we could consider adding a new method called was_scoped that allows the object to specify whether the returned items were scoped. I believe implementing either of these approaches could eliminate the need to clone items repeatedly. What do you think?

@rmosolgo
Copy link
Owner Author

rmosolgo commented Aug 2, 2024

I'm open to adding some kind of flag method on the returned object, something like graphql_list_was_scoped?, and checking that inside GraphQL-Ruby. (I'd rather not go for multiple return ... in places where I've used that, it has added overhead and complexity 😩 )

@alexus37
Copy link

alexus37 commented Aug 3, 2024

I create this issue to track it:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Pundit Integration - Scope and Object Authorization
8 participants